Repository navigation
feat(mcp): delete_records deletes many records of one object in one confirmed call - #31024
objectstack-fleet[bot] wants to merge 10 commits into
Conversation
…onfirmed call (WIP) The tool selects by a filter or an id list, answers an unconfirmed call with delete_record's confirmation refusal plus a preview (count, sample ids), and on confirm deletes each row through the bridge's remove, so every row meets the caller's permissions, row-level security and the package's delete rules; refusals come back grouped by reason. 200 per call. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…(WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…xt and the 200 cap (WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…hangeset; dogfood pins (WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…es not model Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
📓 Docs Drift CheckThis PR changes 2 package(s): 37 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 176 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1cef3bb598cc3a7f78c6152eaf71fb877b8eea18 && git checkout 1cef3bb598cc3a7f78c6152eaf71fb877b8eea18
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 90523379a149e85afa1ed49286a3abaa95075c42 4e5b0f2dc8e8b3e32eda6679524596fd1057b8dd && git checkout -B drift-repro 90523379a149e85afa1ed49286a3abaa95075c42 && git merge --no-ff 4e5b0f2dc8e8b3e32eda6679524596fd1057b8dd
node scripts/docs-audit/affected-docs.mjs --json 90523379a149e85afa1ed49286a3abaa95075c42
|
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH Contract reviewServed-tier: Inputs read: the seat's order ① Derived judgments
② Semver levelBoth carriers read Check-runs on ③ Boundary flags
④ Design judgment
Findings (none must-fix):
Nits:
Implemented-by: VERDICT: PASS Record written 2026-11-06T18:35Z. |
…th data scopes Contract-review fixes on the delete_records tool (#30891): - The filter selection is read at the cap, not one row past it; whether more match is the aggregate count's answer. The cap+1 probe stays only for a bridge without aggregate. The per-call cap follows the deployment's read window down (OS_DATA_MAX_PAGE_SIZE / OS_DATA_QUERY_ROW_BUDGET), since the data door refuses a larger read; an id list longer than that cap is refused before anything is read. - A row's throw is judged with the REST data door's own classifier (mapDataError): a 4xx verdict is a refusal and is grouped, a 5xx verdict is a fault and stops the call, answered isError with the fault, the record it stopped at and the call's progress. - delete_records registers only under both data:read and data:write. - The error-code ledger's ACTION_CONFIRMATION_REQUIRED rows name both delete tools. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…ords bullet #31015's drift pin compares the console's renderObjectStackSkill with the MCP server's renderSkillMarkdown byte for byte; the server's SKILL.md now teaches delete_records, so the console copy carries the same bullet and the tool-name pin lists it. The server-side bullet is reflowed to the file's line width; its words are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
The two new reads in the data:write-only refusal pin were of type unknown, which grew the package's test-typecheck debt; they are cast to the JSON-RPC shapes they read, as the rest of the file's new code should be. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH Contract review · deltaServed-tier: Inputs read: the coordinator's delta request; the two seat claim amendments 6692914335 at 2026-11-06T18:57Z and 6692945782 at 2026-11-06T18:58Z (the two console files, text-only, under #31015's drift pin); the round-2 PR body ① Derived judgments
② Semver levelUnchanged from round 0 in kind: Check-runs on ③ Boundary flags
④ Design judgment
Findings (none must-fix):
Nits:
Implemented-by: VERDICT: PASS (delta) Landing waits on: CI finishing green on Record written 2026-11-06T19:50Z. |
Requested by Tesla · project thread
Clause-②: yes (widening)
Metadata-protocol: none
Fixes #30891
维护者速读
delete_records工具,按筛选条件或 id 列表,一次删除同一对象的多条记录。delete_record、逐条确认;现在先看一次预览(共几条、样例 id),人点头后一次确认删除。delete_record的同一删除路径(权限、行级安全、包的锁定规则),管理员也绕不过;每次最多 200 条(部署的读取页上限更小时按它)。遇到故障(驱动错误、代码钩子崩溃)立即停止并报告已删条数。OAuth 令牌需同时有data:read和data:write。回滚即 revert 本 PR。packages/mcp/src/mcp-http-tools.ts里delete_records的注册段和deleteEach。pnpm --filter @objectstack/mcp exec vitest run src/mcp-delete-records.test.ts(27 条);真实栈上的锁定规则见packages/qa/dogfood/test/mcp-delete-records.dogfood.test.ts。What changes
One new tool on both MCP transports (HTTP
/api/v1/mcpand the stdio server, which shareregisterObjectTools):delete_records. Over OAuth it is offered only to a token that carries bothdata:readanddata:write.Request
{ "objectName": "crm_task", // exactly one of: "where": { "status": "closed" }, // a filter, as query_records takes it; {} = every record you can see "recordIds": ["t1", "t2"], // 1..200 ids "confirm": true // optional boolean; only true deletes }Unconfirmed call = the preview. It reads the selection as the caller and deletes nothing. It answers
delete_record's refusal from #30861 (same member, code, status, envelope keys anddetailskeys;details.actionNameisdelete_records) with apreviewbesideerror:{ "error": { "code": "ACTION_CONFIRMATION_REQUIRED", "message": "delete_records on 'crm_task' carries no confirmation — nothing was deleted. The selection matches 4 record(s); …", "status": 428, "details": { "actionName": "delete_records", "objectName": "crm_task", "confirmationMember": "confirm" } }, "preview": { "object": "crm_task", "matched": 4, "wouldAttempt": 4, "cap": 200, "sampleRecordIds": ["…"], "notFound": ["…"] /* id list only */, "note": "Nothing was deleted. …" } }Confirmed call. Each selected record is deleted through the bridge's
remove, the memberdelete_recorddeletes with, one at a time, under the caller's resolved context. Refused records stay and are grouped by code, status and reason:{ "object": "crm_task", "matched": 4, "attempted": 4, "deleted": 2, "refused": 2, "refusals": [ { "code": "RECORD_LOCKED", "status": 409, "reason": "<the rule's own sentence>", "count": 2, "recordIds": ["…", "…"] } ], "remaining": 2, "cap": 200, "next": "…" // only when more than 200 matched }POST /data/:object/deleteManyrefuses more ids thanbatch.maxBatchSize, default 200. Like that route, this tool deletes one row at a time, so the cap also bounds how long one call runs. A filter is read in id order at the cap, never one row past it, and the bridge'saggregatecount says whether more match. When more than 200 match,remainingandnextsay how many are left and that the same call continues. When refused rows sit inside the first 200,nextnames{"id": {"$nin": [...]}}to move past them. A bridge withoutaggregatekeeps a cap+1 probe and answersmatchedMoreThan: 200, not a guess.OS_DATA_MAX_PAGE_SIZEor pastOS_DATA_QUERY_ROW_BUDGET, and the caller has nolimitto lower. So on a deployment that sets either under 200, one call runs at that size (deleteRecordsCap), and the answer'scapnames it. An id list longer than that cap is refused before anything is read./datadoor's own classifier (mapDataError,@objectstack/types), so the two doors cannot disagree on a row. A 4xx verdict is a refusal and is grouped: a coded rule refusal under the code and messagedelete_recordanswers, a sandboxed hook body's uncoded throw as400 HOOK_ABORTEDwith the body's sentence. A 5xx verdict is a fault: a code hook's barethrow new Error, a lost connection, a driver error, and a driver's own string code (ECONNREFUSED, a SQL state,SQLITE_BUSY), which the door answers500too. The first fault ends the call:isError: true, anerrornaming the fault, the record it stopped at, how many were deleted (they stay deleted) and how many were not attempted, and aprogressmember with the call's answer so far (stoppedAt,notAttempted, nonext).removeasdelete_record, with the context the principal resolver answered, so the engine judges each row'sbeforeDeletehooks, restricted relations, permissions and RLS as it does for a one-row delete.detailswithheld. A refusal is read with the one-row tool's reader (thrownRefusalEnvelope: code, message, status), so a refusal'sdetails(e.g. a permission refusal's positions) stays off the wire, as on every other tool.query_recordsdoes.Also: the SKILL.md tool list and the package README name the tool.
content/docs/ai/connect-mcp.mdxgets the table row and a security-model bullet. In@objectstack/spec, only doc comments change:AIActionConfirmation's docblock namesdelete_recordsas the third door, and the error-code ledger'sACTION_CONFIRMATION_REQUIREDrows name both delete tools. There is one changeset, patch level, for@objectstack/mcp,@objectstack/specand@object-ui/console.Files
packages/mcp/src/mcp-http-tools.ts: the tool,resolveDeleteSelection,deleteEach,rowRefusalOf,deleteRecordsCap.packages/mcp/src/skill-md.ts,packages/mcp/README.md,content/docs/ai/connect-mcp.mdx: teaching.packages/spec/src/contracts/ai-service.ts,packages/spec/src/api/error-code-ledger.zod.ts: doc comments only.ui/apps/console/src/pages/developer/objectstack-skill.tsand one line ofobjectstack-skill.test.ts: the console's "Download skill" text, a byte-for-byte mirror ofskill-md.tsunder fix(console): the skill download teaches delete_record confirm: true and mirrors the served SKILL.md #31015's drift pin, carries the samedelete_recordsbullet. This is outside the card'sui/**fence, under seat claim amendments 6692914335 and 6692945782.mcp-delete-records.test.ts(new), the dogfood file (new), and the tool-surface pins listed under Tests.scripts/engine-double-contract.pinned.json: the new engine double's ledger row..changeset/30891-mcp-delete-records.md.Tests
packages/mcp/src/mcp-delete-records.test.ts(27 tests, real stdio bridge on an engine double whose delete refuses locked rows withRECORD_LOCKED/409, and other users' rows withPERMISSION_DENIED/403 carryingdetails):confirm,recordIds.maxItems200;engine.deletenever called, all rows intact;false,"true"and1are refused too;delete_record's code, status, envelope keys anddetailskeys;detailswithheld, one engine delete per row;isSystem, no copy);notFound;remaining: 5andnext, the second finishes; over the cap with refused rows,nextnames$nin;aggregate:matchedMoreThan: 200;success: falselands innotRemoved;planQueryReadWindowin front of the bridge: a page size equal to the cap reads at 200 and counts 205 (a lit control shows the window refusing 201); exactly 200 matching has nonext; a decimal-string count is read; a page size or row budget of 50 lowers the cap to 50; an id list longer than a lowered cap is refused before any read;ECONNREFUSEDeach stop the call at that row (isError, the record, the deleted count,progress, later rows never asked); a sandboxed body's uncoded refusal is grouped asHOOK_ABORTED/400 and the call goes on;packages/qa/dogfood/test/mcp-delete-records.dogfood.test.ts: a real stack with a packagebeforeDeletelock in HotCRM's shape, called by the administrator over the live/api/v1/mcp. It covers:DELETEcontrol (409 for the same admin);RECORD_LOCKED/409 group with the hook's own sentence, and still readable;OS_DATA_MAX_PAGE_SIZEis 200 (lit control: REST?limit=201answers 400MAX_RECORDS_EXCEEDED);HOOK_ABORTED/400 while the walk goes on, then a code hook's barethrow new Errorstopping it, with RESTDELETEas the control (400 and 500 on the same rows).delete_recordsonly under both data scopes, and adata:write-onlytools/call delete_recordsrefused with noquery,aggregateorremovereaching the bridge.delete_records.Acceptance notes
BatchOptions.validateOnlywas retired for promising one). Running the delete in a rolled-back transaction would still fireafterDeletehooks, which do not wait for a commit. So the preview gives the count and a sample and says the rules are judged when the delete runs. The confirmed call's answer carries the grouped refusals with ids.data:writewithoutdata:readis not offered the tool.delete_recordstays underdata:writealone.limitthe caller never sent, so the cap takes the window's size instead.400 HOOK_ABORTED), and a driver's errors often carry string codes (ECONNREFUSED,57P01,SQLITE_BUSY; REST answers them500). So the tool asks the REST door's classifier and stops on its 5xx verdicts.apiMethodsexcludelistcannot usedelete_records. The selection is a list-class read, so the exposure gate refuses it.delete_recordstill works there.skills/objectstack-ai/SKILL.mdteachingdelete_records(skills/**is outside this card).connect-mcp.mdx, the unknown-argument-keys test, theskill-md.tsimports), and the merged tool count is 15. Whichever lands second takes them, and must also mirror its ownskill-md.tslines into the console copy. This branch does not merge feat(mcp): read_file and create_upload_link read and attach a record's files #31009's. origin/main was merged in again at17a02908(fix(console): the skill download teaches delete_record confirm: true and mirrors the served SKILL.md #31015's drift pin arrived there).Verification
Head
4e5b0f2d(origin/main merged at17a02908):pnpm --filter @objectstack/mcp test: 58 files, 725 tests passed.mcp-delete-records.test.tshas 27.pnpm --filter @objectstack/mcp run typecheck(tsc --noEmitpluscheck:test-typecheck, debt ledger held) exited 0.mcp-delete-records.dogfood.test.ts(7) andmcp-delete-record-confirmation.dogfood.test.ts(4) passed, 11/11, after a full./packages/**+@objectstack/dogfood^...build. The dogfood resolves@objectstack/mcpfrom dist.pnpm --dir ui exec vitest run apps/console/src/pages/developer/objectstack-skill.test.ts, 13/13..tsfile (packages and the two console files) exited 0.Ablations, round 1: four new negative pins, each mutated once through
scripts/ablation-replace.mjs(WRAP mode), each red, each restored.mcp-http-tools.tsis byte-identical on4e5b0f2d(blobd06c686d).B1. A
data:write-only token is refused. Mutation:if (canRead) {becomesif (true) {, so the tool registers underdata:writealone.B1 ran before the round's commit; its restore put the file back to the older HEAD. The round's edits were replayed and proved byte-identical (blob
d06c686d, the blob B1 mutated), then committed before B2–B4.B2. A fault stops the call. Mutation:
if (door.status >= 500) return undefined;becomesif (false) return undefined;, so a 5xx verdict is grouped like a refusal.B3. The selection is read at the cap. Mutation:
const ids = await readIds(cap);becomesconst ids = (await readIds(cap + 1)).slice(0, cap);, the old probe.B4. The cap follows the read window down. Mutation:
deleteRecordsCapreturnsDELETE_RECORDS_MAX_PER_CALLunconditionally.The dogfood runs for B2 and B3 rebuilt
@objectstack/mcp's dist inside the mutation; it was rebuilt again after the restore.Ablations from the first round (A1–A3), on
ef1973df, unchanged by this roundremovehands the engine{ ...ec, skipAutomations: true }(the admin-bypass shape): the dogfood admin pin red (deleted4,refused0); the REST control stayed green.Gates on
4e5b0f2d:dispatch-gates.mjs --commandsderived 116 commands.check-published-list-mirrors.mjs, speccheck:meta-url-spelling,check:authz-resolver,check:changeset-fixed,check:facade-delegation-only,check:filter-alias-parity,check:i18n-stale-fillandcheck:published-readme-exports./dev/null, and every one exited 0.dispatch-gates --ranreconciled 116 derived, 116 run, 0 NOT-MEASURED.server-seat-2/r2026-11-06/out/30891-gates-ran-4e5b0f2d.listin the project files.changeset statusandcheck:changeset-fixedexited 0.Notes on the gate runs
e223b854.check:where-matcherexited 1 on a real finding: the new test's engine double read a combinator ($and,$or) as a field name. It now refuses any$-key inside itseverycallback (a7989233).check:test-typecheckexited 1 on a real finding: the new scope pin read two JSON-RPC answers asunknown, growing the package's debt. They are typed (4e5b0f2d).client-reactdist, the dual-build dists and pinned commit621a4876were built or fetched first, as in round 0../packages/**+@objectstack/dogfood^...build on the merged head.